package tv.dyndns.kishibe.server.database;

import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;

import org.apache.commons.io.FileUtils;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.Field.Index;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.document.NumericField;
import org.apache.lucene.index.CorruptIndexException;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.NumericRangeQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.similar.MoreLikeThisQuery;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.LockObtainFailedException;

import tv.dyndns.kishibe.client.game.ProblemGenre;
import tv.dyndns.kishibe.client.game.ProblemType;
import tv.dyndns.kishibe.client.game.RandomFlag;
import tv.dyndns.kishibe.client.packet.PacketProblem;
import tv.dyndns.kishibe.client.util.HasIndex;
import tv.dyndns.kishibe.server.ThreadPool;
import tv.dyndns.kishibe.server.relevance.LuceneVersion;
import tv.dyndns.kishibe.server.relevance.NGramAnalyzer;
import tv.dyndns.kishibe.server.relevance.ViterbiAnalyzer;
import tv.dyndns.kishibe.server.relevance.ViterbiTokenizer;
import tv.dyndns.kishibe.server.util.IntArray;
import tv.dyndns.kishibe.server.util.Normalizer;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.primitives.Ints;
import com.google.inject.Inject;

public class FullTextSearch {
	private static final Logger logger = Logger.getLogger(FullTextSearch.class.toString());
	private static final File INDEX_FILE_DIRECTORY = new File("/tmp/qmaclone/lucene");
	private static final int TIME_OUT = 10000;
	private static final int MAX_NUMBER_OF_SEARCH_REUSLTS = 10000;
	private static final String FIELD_PROBLEM_ID = "problemId";
	private static final String FIELD_SENTENCE = "sentence";
	private static final String FIELD_SEARCH = "search";
	private static final String FIELD_CREATOR = "creator";
	private static final String FIELD_CREATOR_DIRECTLY = "creatorDirectly";
	private static final String FIELD_GENRE = "genre";
	private static final String FIELD_TYPE = "type";
	private static final String FIELD_RANDOM_FLAG = "randomFlag";
	private static final String FIELD_SIMILAR = "similar";
	private static final String LUCENE_ESCAPE_CHARS = "(&&)|(\\|\\|)|[\\+\\-\\!\\(\\)\\{\\}\\[\\]\\^\\\"\\~\\*\\?\\:\\\\]";
	private static final Pattern LUCENE_PATTERN = Pattern.compile(LUCENE_ESCAPE_CHARS);
	private static final String REPLACEMENT_STRING = "\\\\$0";
	private static final String PREFIX_GENRE = "ジャンル:";
	private static final String PREFIX_TYPE = "問題形式:";
	private static final String PREFIX_CREATOR = "問題作成者:";
	private static final String PREFIX_RANDOM = "ランダム:";
	private final Analyzer analyzer = new NGramAnalyzer();
	private final Object lockIndexWriter = new Object();
	private final DatabaseConnectionPool databaseConnectionPool;
	private final ThreadPool threadPool;

	@Inject
	public FullTextSearch(DatabaseConnectionPool databaseConnectionPool, ThreadPool threadPool) {
		this.databaseConnectionPool = databaseConnectionPool;
		this.threadPool = threadPool;
		if (!isIndexExists()) {
			try {
				generateIndex();
			} catch (IOException | DatabaseException e) {
				logger.log(Level.WARNING, "インデクスの作成に失敗しました", e);
			}
		}
	}

	private boolean isIndexExists() {
		try {
			return IndexReader.indexExists(FSDirectory.open(INDEX_FILE_DIRECTORY));
		} catch (IOException e) {
			logger.log(Level.WARNING, "インデクスの存在確認に失敗しました", e);
		}
		return false;
	}

	private void generateIndex() throws IOException, DatabaseException {
		FileUtils.deleteDirectory(INDEX_FILE_DIRECTORY);
		INDEX_FILE_DIRECTORY.mkdirs();

		synchronized (lockIndexWriter) {
			FSDirectory d = FSDirectory.open(INDEX_FILE_DIRECTORY);
			try (IndexWriter writer = new IndexWriter(d, new IndexWriterConfig(LuceneVersion.get(),
					analyzer).setOpenMode(OpenMode.CREATE))) {
				// 循環参照のため直接インスタンス化する
				new DirectDatabase(databaseConnectionPool, null, null)
						.processProblems(new ProblemIndexWriter(writer));
			}
		}
	}

	private IndexWriter newIndexWriter() throws CorruptIndexException, LockObtainFailedException,
			IOException {
		return new IndexWriter(FSDirectory.open(INDEX_FILE_DIRECTORY), new IndexWriterConfig(
				LuceneVersion.get(), analyzer).setOpenMode(OpenMode.APPEND));
	}

	private Document convertProblemToDocument(PacketProblem problem) {
		Document document = new Document();

		// 問題番号
		String problemId = String.valueOf(problem.id);
		document.add(new Field(FIELD_PROBLEM_ID, problemId, Store.YES, Index.NOT_ANALYZED));

		// 問題文
		String sentence = problem.sentence;
		document.add(new Field(FIELD_SENTENCE, sentence, Store.NO, Index.ANALYZED));

		// 問題文+選択肢+解答+問題ノート
		String searchQuery = problem.getSearchQuery();
		document.add(new Field(FIELD_SEARCH, searchQuery, Store.NO, Index.ANALYZED));

		// 作問者
		String creator = problem.creator;
		document.add(new Field(FIELD_CREATOR, creator, Store.YES, Index.ANALYZED));

		// 作問者
		String creatorDirectly = problem.creator;
		document.add(new Field(FIELD_CREATOR_DIRECTLY, creatorDirectly, Store.NO,
				Index.NOT_ANALYZED));

		// ジャンル
		NumericField numericField = new NumericField(FIELD_GENRE, Store.YES, true);
		numericField.setIntValue(problem.genre.getIndex());
		document.add(numericField);

		// 出題形式
		numericField = new NumericField(FIELD_TYPE, Store.YES, true);
		numericField.setIntValue(problem.type.getIndex());
		document.add(numericField);

		// ランダム
		numericField = new NumericField(FIELD_RANDOM_FLAG, Store.YES, true);
		numericField.setIntValue(problem.randomFlag.getIndex());
		document.add(numericField);

		// 類似問題検索
		String similar = problem.getSearchQuery();
		document.add(new Field(FIELD_SIMILAR, new ViterbiTokenizer(new StringReader(similar))));

		return document;
	}

	private class ProblemIndexWriter implements ProblemProcessable {
		private final IndexWriter indexWriter;

		public ProblemIndexWriter(IndexWriter indexWriter) {
			this.indexWriter = indexWriter;
		}

		@Override
		public void process(PacketProblem problem) throws Exception {
			Document document = convertProblemToDocument(problem);
			try {
				indexWriter.addDocument(document);
			} catch (CorruptIndexException e) {
				logger.log(Level.WARNING, "インデクスが破損しています", e);
			} catch (IOException e) {
				logger.log(Level.WARNING, "インデクスへの書き込みに失敗しました", e);
			}
		}
	}

	private Query wordToQuery(String field, String word) throws IOException {
		BooleanQuery query = new BooleanQuery();
		TokenStream ts = analyzer.tokenStream(field, new StringReader(word));
		CharTermAttribute termAttribute = ts.getAttribute(CharTermAttribute.class);
		while (ts.incrementToken()) {
			query.add(new TermQuery(new Term(field, termAttribute.toString())), Occur.MUST);
		}

		return query;
	}

	private Query stringToQuery(String field, String string) throws IOException {
		BooleanQuery query = new BooleanQuery();

		StringTokenizer st = new StringTokenizer(string);
		while (st.hasMoreTokens()) {
			String word = st.nextToken();
			if (word.startsWith("-")) {
				query.add(wordToQuery(field, word.substring(1)), Occur.MUST_NOT);
			} else {
				query.add(wordToQuery(field, word), Occur.MUST);
			}
		}

		return query;
	}

	public static String escapeQuery(String t) {
		return LUCENE_PATTERN.matcher(t).replaceAll(REPLACEMENT_STRING);
	}

	public Map<String, IntArray> getThemeModeProblemMinimums(
			Map<String, List<String>> themeAndQueryStrings) {
		ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime()
				.availableProcessors());

		final Map<String, IntArray> themeToProblems = Maps.newConcurrentMap();

		for (final Entry<String, List<String>> entry : themeAndQueryStrings.entrySet()) {
			executorService.submit(new Runnable() {
				@Override
				public void run() {
					String theme = entry.getKey();
					List<String> queryStrings = entry.getValue();

					IntArray problemIds = null;
					try {
						problemIds = searchProblemsForThemeMode(queryStrings);
					} catch (Exception e) {
						// 何もしない
					}

					if (problemIds == null) {
						return;
					}

					themeToProblems.put(theme, problemIds);
				}
			});
		}

		executorService.shutdown();
		try {
			executorService.awaitTermination(1, TimeUnit.HOURS);
		} catch (InterruptedException e) {
			// 何もしない
		}

		return ImmutableMap.copyOf(themeToProblems);
	}

	private Query queryStringToThemeModeQuery(String string) throws IOException {
		BooleanQuery query = new BooleanQuery();

		StringTokenizer st = new StringTokenizer(string);
		while (st.hasMoreTokens()) {
			String word = st.nextToken();
			Occur occur = Occur.MUST;
			if (word.startsWith("-")) {
				word = word.substring(1);
				occur = Occur.MUST_NOT;
			}

			if (word.startsWith(PREFIX_GENRE)) {
				// ジャンル
				ProblemGenre genre = ProblemGenre.fromName(word.substring(PREFIX_GENRE.length()));
				if (genre == null) {
					continue;
				}
				query.add(
						NumericRangeQuery.newIntRange(FIELD_GENRE, genre.getIndex(),
								genre.getIndex(), true, true), occur);

			} else if (word.startsWith(PREFIX_TYPE)) {
				// 出題形式
				ProblemType type = ProblemType.fromName(word.substring(PREFIX_TYPE.length()));
				if (type == null) {
					continue;
				}
				query.add(NumericRangeQuery.newIntRange(FIELD_TYPE, type.getIndex(),
						type.getIndex(), true, true), occur);

			} else if (word.startsWith(PREFIX_CREATOR)) {
				// 問題作成者
				String creator = word.substring(PREFIX_CREATOR.length());
				query.add(new TermQuery(new Term(FIELD_CREATOR_DIRECTLY, creator)), occur);

			} else if (word.startsWith(PREFIX_RANDOM)) {
				// 問題作成者
				String random = word.substring(PREFIX_RANDOM.length());
				random = Normalizer.normalize(random);
				if (Ints.tryParse(random) == null) {
					continue;
				}
				int randomFlag = Ints.tryParse(random);
				query.add(NumericRangeQuery.newIntRange(FIELD_RANDOM_FLAG, randomFlag, randomFlag,
						true, true), occur);

			} else {
				// 問題文
				query.add(wordToQuery(FIELD_SENTENCE, word), occur);
			}
		}

		return query;
	}

	@VisibleForTesting
	IntArray searchProblemsForThemeMode(List<String> queryStrings) throws CorruptIndexException,
			IOException {
		try (IndexReader reader = IndexReader.open(FSDirectory.open(INDEX_FILE_DIRECTORY));
				IndexSearcher searcher = new IndexSearcher(reader)) {

			// クエリ生成 加法標準形
			BooleanQuery query = new BooleanQuery();
			for (String queryString : queryStrings) {
				query.add(queryStringToThemeModeQuery(queryString), Occur.SHOULD);
			}

			IntArray problemIds = new IntArray();

			TopDocs docs = searcher.search(query, Integer.MAX_VALUE);

			for (ScoreDoc doc : docs.scoreDocs) {
				Document document = reader.document(doc.doc);
				int problemId = Integer.parseInt(document.get(FIELD_PROBLEM_ID));
				problemIds.add(problemId);
			}

			return problemIds;
		}
	}

	public void addProblem(PacketProblem problem) throws CorruptIndexException,
			LockObtainFailedException, IOException {
		synchronized (lockIndexWriter) {
			Document document = convertProblemToDocument(problem);
			try (IndexWriter indexWriter = newIndexWriter()) {
				indexWriter.addDocument(document);
			}
		}
	}

	public void updateProblem(PacketProblem problem) throws CorruptIndexException,
			LockObtainFailedException, IOException {
		synchronized (lockIndexWriter) {
			Document document = convertProblemToDocument(problem);
			try (IndexWriter indexWriter = newIndexWriter()) {
				indexWriter.updateDocument(new Term(FIELD_PROBLEM_ID, "" + problem.id), document);
			}
		}
	}

	private <T extends Enum<T> & HasIndex> Query toRangeQuery(String field, Set<T> set) {
		Set<Integer> indexes = Sets.newHashSet();
		for (T element : set) {
			indexes.add(element.getIndex());
		}

		BooleanQuery query = new BooleanQuery();
		int begin = -1;
		for (int i = 0; i < 31; ++i) {
			if (indexes.contains(i)) {
				if (begin == -1) {
					begin = i;
				}
			} else {
				if (begin == -1) {
					continue;
				}
				Query q = NumericRangeQuery.newIntRange(field, begin, i, true, false);
				query.add(q, Occur.SHOULD);
				begin = -1;
			}
		}

		return query;
	}

	public List<Integer> searchProblem(final String queryString, final String creator,
			final boolean creatorPerfectMatching, final Set<ProblemGenre> genresFinal,
			final Set<ProblemType> typesFinal, final Set<RandomFlag> randomFlagsFinal) {
		Future<List<Integer>> future = threadPool.submit(new Callable<List<Integer>>() {
			@Override
			public List<Integer> call() throws Exception {
				Set<ProblemGenre> genres = Sets.newEnumSet(genresFinal, ProblemGenre.class);
				Set<ProblemType> types = Sets.newEnumSet(typesFinal, ProblemType.class);
				Set<RandomFlag> randomFlags = Sets.newEnumSet(randomFlagsFinal, RandomFlag.class);

				boolean queryEmpty = Strings.isNullOrEmpty(queryString);
				boolean creatorEmpty = Strings.isNullOrEmpty(creator);
				boolean genreEmpty = (genres.isEmpty() || genres.equals(EnumSet
						.of(ProblemGenre.Random)));
				boolean typeEmpty = (types.isEmpty() || types.equals(EnumSet.of(ProblemType.Random)));
				if (queryEmpty && creatorEmpty && genreEmpty && typeEmpty) {
					return new ArrayList<Integer>();
				}

				if (genres.isEmpty() || genres.contains(ProblemGenre.Random)) {
					genres = EnumSet.complementOf(EnumSet.of(ProblemGenre.Random));
				}

				if (types.isEmpty() || types.contains(ProblemType.Random)) {
					types = EnumSet.complementOf(EnumSet.of(ProblemType.Random));
				}

				if (randomFlags.isEmpty() || randomFlags.contains(RandomFlag.NotSelected)) {
					randomFlags = EnumSet.complementOf(EnumSet.of(RandomFlag.NotSelected));
				}

				BooleanQuery query = new BooleanQuery();

				// 問題文
				if (!queryEmpty) {
					try {
						query.add(stringToQuery(FIELD_SEARCH, queryString), Occur.MUST);
					} catch (Exception e) {
						logger.log(Level.WARNING, "クエリの追加に失敗しました", e);
						return null;
					}
				}

				// 作成者
				if (!creatorEmpty) {
					if (creatorPerfectMatching) {
						query.add(new TermQuery(new Term(FIELD_CREATOR_DIRECTLY, creator)),
								Occur.MUST);
					} else {
						try {
							query.add(stringToQuery(FIELD_CREATOR, creator), Occur.MUST);
						} catch (Exception e) {
							logger.log(Level.WARNING, "クエリの追加に失敗しました", e);
							return null;
						}
					}
				}

				// ジャンル
				query.add(toRangeQuery(FIELD_GENRE, genres), Occur.MUST);

				// 出題形式
				query.add(toRangeQuery(FIELD_TYPE, types), Occur.MUST);

				// ランダムフラグ
				query.add(toRangeQuery(FIELD_RANDOM_FLAG, randomFlags), Occur.MUST);

				try (IndexReader reader = IndexReader.open(FSDirectory.open(INDEX_FILE_DIRECTORY));
						IndexSearcher searcher = new IndexSearcher(reader)) {
					TopDocs docs = searcher.search(query, MAX_NUMBER_OF_SEARCH_REUSLTS);
					List<Integer> problemIds = new ArrayList<Integer>(docs.scoreDocs.length);
					for (ScoreDoc doc : docs.scoreDocs) {
						Document document = reader.document(doc.doc);
						int problemId = Integer.parseInt(document.get(FIELD_PROBLEM_ID));
						problemIds.add(problemId);
					}

					return problemIds;
				}
			}
		});

		try {
			return future.get(TIME_OUT, TimeUnit.SECONDS);
		} catch (Exception e) {
			Object[] args = { queryString, creator, genresFinal, typesFinal, randomFlagsFinal };
			logger.log(Level.WARNING, "問題検索でタイムアウトが発生しました " + Arrays.deepToString(args), e);
			return null;
		}
	}

	public List<Integer> searchSimilarProblemFromDatabase(final PacketProblem problem) {
		Future<List<Integer>> future = threadPool.submit(new Callable<List<Integer>>() {
			@Override
			public List<Integer> call() throws Exception {
				try (IndexReader reader = IndexReader.open(FSDirectory.open(INDEX_FILE_DIRECTORY));
						IndexSearcher searcher = new IndexSearcher(reader)) {
					String[] fields = new String[] { FIELD_SIMILAR };
					String searchQuery = problem.getSearchQuery();
					MoreLikeThisQuery query = new MoreLikeThisQuery(searchQuery, fields,
							new ViterbiAnalyzer(LuceneVersion.get()),
							"similar_problem_search_field");

					TopDocs docs = searcher.search(query, 10);
					List<Integer> problemIds = new ArrayList<Integer>(docs.scoreDocs.length);
					for (ScoreDoc doc : docs.scoreDocs) {
						Document document = reader.document(doc.doc);
						int problemId = Integer.parseInt(document.get(FIELD_PROBLEM_ID));
						problemIds.add(problemId);
					}

					return problemIds;

				}
			}
		});

		try {
			return future.get(TIME_OUT, TimeUnit.SECONDS);
		} catch (Exception e) {
			logger.log(Level.WARNING, "類似問題検索でタイムアウトが発生しました " + problem.toString(), e);
			return null;
		}
	}
}
